import math

class Value:
    def __init__(self, data: float | int, op: str ='', prev=()):
        """
        New a value with `data`

        >>> v = Value(2)
        >>> v.data
        2
        >>> v = Value(12.3, op='+')
        >>> v._op
        '+'
        """
        self.data = data
        self.grad = 0
        self._backward = None
        self._prev = set(prev)
        self._op = op

    def __add__(self, other):
        """
        >>> v1 = Value(2)
        >>> v2 = Value(3)
        >>> v3 = v1 + v2
        >>> v3.data
        5
        >>> v3 = v3 + 9
        >>> v3.data
        14
        """
        # Wrap `other` to a Value if it's float or int
        other = other if isinstance(other, Value) else Value(other)
        out = Value(self.data + other.data, op='+', prev=(self, other))
        
        """
            y = a + b
            dy/da = 1
            dy/db = 1
        """
        def _backward():
            self.grad += out.grad
            other.grad += out.grad
        out._backward = _backward
        
        return out
    
    def __mul__(self, other):
        """
        >>> v1 = Value(2.)
        >>> v2 = Value(3)
        >>> v3 = v1 * v2
        >>> v3.data
        6.0
        >>> v3 = v3 * 1.5
        >>> v3.data
        9.0
        """
        other = other if isinstance(other, Value) else Value(other)
        out = Value(self.data * other.data, op='*', prev=(self, other))
        
        """
            y = a * b
            dy/da = b
            dy/db = a
        """
        def _backward():
            self.grad += out.grad * other.data
            other.grad += out.grad * self.data
        out._backward = _backward
        
        return out
    
    def __sub__(self, other):
        """
        >>> v1 = Value(2)
        >>> v2 = Value(3)
        >>> v3 = v1 - v2
        >>> v3.data
        -1
        >>> v3 = v3 - 1
        >>> v3.data
        -2
        """
        other = other if isinstance(other, Value) else Value(other)
        out = Value(self.data - other.data, op='-', prev=(self, other))
        
        """
            y = a - b
            dy/da = 1
            dy/db = -1
        """
        def _backward():
            self.grad += out.grad
            other.grad += -out.grad
        out._backward = _backward
        
        return out
    
    def __truediv__(self, other):
        """
        >>> v1 = Value(4)
        >>> v2 = Value(2)
        >>> v3 = v1 / v2
        >>> v3.data
        2.0
        >>> v3 = v3 / 10
        >>> v3.data
        0.2
        """
        other = other if isinstance(other, Value) else Value(other)
        out = Value(self.data / other.data, op='/', prev=(self, other))
        
        """
            y = a / b = a * b**-1
            dy/da = 1/b
            dy/db = -a * b**-2
        """
        def _backward():
            self.grad += out.grad * (1 / other.data)
            other.grad += out.grad * (-self.data * other.data**-2)
        out._backward = _backward
        
        return out

    def __pow__(self, other): 
        """
        >>> v = Value(2)
        >>> v = v**2
        >>> v.data
        4
        >>> v = v**-1
        >>> v.data
        0.25
        """
        assert isinstance(other, (int, float)), "only supporting int/float powers for now"
        out = Value(self.data**other, op=f'**{other}', prev=(self,))
        
        """
            y = a**k
            dy/da = k * a**(k-1)
        """
        def _backward():
            self.grad += other * self.data**(other-1)
        out._backward = _backward
        
        return out
    
    def exp(self):
        """
        >>> v = Value(2.).exp()
        """
        out = Value(math.exp(self.data), op='exp', prev=(self,))

        """
            y = e**x
        """
        def _backward():
            self.grad += out.grad * out.data
        out._backward = _backward

        return out
    
    def relu(self):
        """
        >>> v = Value(2).relu()
        >>> v.data
        2
        >>> v = Value(-1).relu()
        >>> v.data
        0
        """
        out = Value(0 if self.data < 0 else self.data, op='ReLU', prev=(self,))

        """
            y = x if x > 0 else 0
        """
        def _backward():
            self.grad += out.grad * (out.data > 0)
        out._backward = _backward

        return out
    
    def sigmoid(self):
        """
        >>> x = Value(2.)
        >>> y = x.sigmoid()
        >>> y.data
        0.8807970779778823
        """
        return 1 / (1 + (-self).exp())
        
    def __neg__(self):
        """
        >>> v = Value(-12.3)
        >>> v = -v
        >>> v.data
        12.3
        """
        return self * -1
    
    def __radd__(self, other):
        """
        >>> v = 2 + Value(2)
        >>> v.data
        4
        """
        return self + other
    
    def __rsub__(self, other):
        """
        >>> v = 12 - Value(2)
        >>> v.data
        10
        """
        return -self + other
    
    def __rmul__(self, other):
        """
        >>> v = 10 * Value(2)
        >>> v.data
        20
        """
        return self * other
    
    def __rtruediv__(self, other):
        """
        >>> v = 10 / Value(2)
        >>> v.data
        5.0
        """
        return other * self**-1

    def __repr__(self):
        return f"Value(data={self.data}, grad={self.grad})"
    
    def backward(self):
        """
        >>> a = Value(3)
        >>> b = Value(4)
        >>> y = a + b
        >>> y.backward()
        >>> a.grad
        1
        >>> b.grad       
        1
        >>> 
        >>> x = Value(2)
        >>> y = x + x
        >>> y.backward()
        >>> x.grad
        2
        >>> a, b = Value(3), Value(4)
        >>> y = a * b
        >>> y.backward()
        >>> a.grad
        4
        >>> b.grad       
        3
        >>> 
        >>> a, b = Value(2), Value(5)
        >>> y = a / b
        >>> y.backward()
        >>> a.grad
        0.2
        >>> b.grad  
        -0.08
        >>> 
        >>> x1, x2 = Value(1.2), Value(2.5)
        >>> y = 3 * x1 - 7.1 * x2
        >>> y.backward()
        >>> x1.grad
        3
        >>> x2.grad  
        -7.1
        """
        # Topological order all of the children in the graph
        topo: list[Value] = []
        visited = set()
        def build_topo(v: Value):
            if v not in visited:
                visited.add(v)
                for child in v._prev:
                    build_topo(child)
                topo.append(v)

        build_topo(self)

        # Go one variable at a time and apply the chain rule to get its gradient
        self.grad = 1
        for v in reversed(topo):
            if v._backward is not None:
                v._backward()